Skip to content

Add chat gateway#2

Open
johannesschiessl wants to merge 2 commits into
mainfrom
t3code/chat-gateway
Open

Add chat gateway#2
johannesschiessl wants to merge 2 commits into
mainfrom
t3code/chat-gateway

Conversation

@johannesschiessl

@johannesschiessl johannesschiessl commented Jul 19, 2026

Copy link
Copy Markdown
Member

What changed

  • added an Effect-native gateway service with normalized incoming/outgoing messages, adapter-qualified channel authorization, discrete sends, and bounded outgoing stream consumption
  • added strict ~/.compass/gateway.json loading with non-secret Telegram polling configuration and deny-by-default allowed channels
  • added atomic local attachment storage under ~/.compass/files, including streamed inbound downloads and configurable size limits
  • added a complete Telegram Bot API adapter using scoped long polling, offset advancement, capped retry backoff, typed response decoding, media downloads, and text/media sends
  • moved the reusable gateway schemas, branded types, tagged errors, and adapter interface into @compass/contracts with dedicated contract tests
  • added a ready-to-use live layer plus focused gateway, configuration, filesystem, polling, media, and HTTP boundary tests
  • added the dark-mode HTML implementation plan in .plans/02-chat-gateway.html

Why

Compass needs a small reusable chat transport boundary without the broader Vercel Chat SDK feature set. This keeps platform behavior behind adapters while preserving Effect-native resource management, typed failures, backpressure, and testability.

Impact

Applications can consume a unified incoming stream and send finished messages or files to allowlisted channels. Telegram is supported now through polling; future adapters can implement the same platform-neutral contract.

Validation

  • vp run ready
  • vp run typecheck
  • 33 tests passing, including 14 gateway-focused contract and runtime tests

Review in cubic

@johannesschiessl johannesschiessl changed the title Add Effect-native chat gateway Add chat gateway Jul 20, 2026
@johannesschiessl
johannesschiessl marked this pull request as ready for review July 20, 2026 20:14

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 19 files

Confidence score: 2/5

  • In apps/server/src/gateway/Gateway.ts, using Stream.mergeAll without per-adapter error isolation means one transient adapter failure can terminate the entire incoming pipeline, potentially stopping message intake system-wide—add adapter-level catch/restart behavior (or supervised retries) before merging.
  • In apps/server/src/gateway/adapters/telegram/TelegramAdapter.ts, retry delay capping can ignore a configured retryMaxDelayMillis when it is below the base delay, so polling may back off longer than operators expect during incidents—enforce the configured max as a hard cap (or fail fast on invalid config) before merging.
  • In apps/server/src/gateway/adapters/telegram/TelegramModels.ts, schema drift from Telegram’s contract (file_unique_id marked optional, integer-only fields accepted as any finite number) can mask payload/data-shape problems and cause subtle downstream assumptions to break—tighten these model constraints or document and test the intentional looseness before merging.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/server/src/gateway/adapters/telegram/TelegramAdapter.ts">

<violation number="1" location="apps/server/src/gateway/adapters/telegram/TelegramAdapter.ts:286">
P2: A configured retry maximum below the base delay is ignored, so polling can wait longer than `retryMaxDelayMillis`. Use the configured maximum as the cap (or reject this invalid config combination).</violation>
</file>

<file name="apps/server/src/gateway/Gateway.ts">

<violation number="1" location="apps/server/src/gateway/Gateway.ts:56">
P1: Missing error isolation in merged incoming stream: `Stream.mergeAll` terminates the entire stream when any single adapter stream fails. A transient network error in the Telegram adapter would cut off incoming messages from all adapters. Wrap each adapter's stream with `Stream.catchAll` or `Stream.catchTag` to isolate failures per adapter (log and resume, or restart the adapter stream) so one adapter's error doesn't bring down the whole gateway.</violation>
</file>

<file name="apps/server/src/gateway/adapters/telegram/TelegramModels.ts">

<violation number="1" location="apps/server/src/gateway/adapters/telegram/TelegramModels.ts:5">
P2: `file_unique_id` in `TelegramFileFields` is modeled with `Schema.optionalKey`, but the Telegram Bot API guarantees `file_unique_id` is always present on all file objects (`File`, `PhotoSize`, `Document`, `Audio`, `Video`, `Voice`). This means the TypeScript type will have `file_unique_id?: string | undefined` when Telegram never omits it, forcing unnecessary defensive `??` / `?.` checks in consumer code. Use `Schema.String` instead.</violation>

<violation number="2" location="apps/server/src/gateway/adapters/telegram/TelegramModels.ts:10">
P3: Numeric fields like `id`, `message_id`, `date`, `width`, `height`, `duration`, and `file_size` use `Schema.Finite`, which accepts any finite number including floats. The Telegram Bot API always returns integers for these fields. The codebase already has `const NonNegativeInteger = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))` in `packages/contracts/src/gateway.ts` — using that pattern (or at minimum `Schema.Finite.check(Schema.isInt())`) here would catch non-integer values that `Schema.Finite` silently accepts.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant App as Application
    participant Gateway as Gateway Service
    participant Config as GatewayConfig
    participant Adapter as TelegramAdapter
    participant Api as TelegramApi
    participant Files as GatewayFiles
    participant TelegramAPI as Telegram Bot API

    Note over App,TelegramAPI: Incoming message flow (long polling)

    App->>Gateway: subscribe to incoming stream
    Gateway->>Adapter: merge adapter streams

    loop Poll loop
        Adapter->>Api: getUpdates(offset, timeout, limit)
        Api->>TelegramAPI: HTTP POST /getUpdates
        TelegramAPI-->>Api: array of updates
        Api-->>Adapter: raw updates
        Adapter->>Adapter: decode & filter supported messages
        alt Valid message with content
            Adapter->>Adapter: create IncomingAdapterEvent (channel, lazy materialize)
            Adapter-->>Gateway: emit event
            Gateway->>Config: check allowedChannels
            alt Channel allowed
                Gateway->>Adapter: call materialize
                Adapter->>Api: getFile(fileId)
                Api->>TelegramAPI: HTTP GET /getFile
                TelegramAPI-->>Api: file metadata (path, size)
                Api-->>Adapter: TelegramFile
                alt file_size <= maxInboundFileBytes
                    Adapter->>Api: download(filePath) – stream
                    Api->>TelegramAPI: HTTP GET /file/bot{token}/{path}
                    TelegramAPI-->>Api: byte stream
                    Adapter->>Files: saveStream(stream, preferredName, kind, maxBytes)
                    Files->>Files: atomic write to ~/.compass/files/<uuid>-<name>
                    Files-->>Adapter: GatewayFile
                    Adapter->>Adapter: construct IncomingMessage (text, files, sender)
                    Adapter-->>Gateway: IncomingMessage
                    Gateway-->>App: push to incoming stream
                else file_size > limit
                    Adapter-->>Gateway: GatewayFileError
                    Gateway->>Gateway: log warning, skip message
                end
            else Channel denied
                Gateway->>Gateway: filter event – materialize NOT called
            end
        else Malformed or unsupported update
            Adapter->>Adapter: log warning, advance offset
        end
    end

    Note over App,TelegramAPI: Outgoing message flow (send)

    App->>Gateway: send(OutgoingMessage)
    Gateway->>Config: check allowedChannels
    alt Channel not allowed
        Gateway-->>App: GatewayAuthorizationError
    else Channel allowed
        Gateway->>Gateway: validate message (text or files)
        alt Invalid (empty text + no files)
            Gateway-->>App: GatewayInvalidMessageError
        else Valid
            Gateway->>Gateway: parseChannelId -> adapter name
            alt Adapter not found
                Gateway-->>App: GatewayAdapterNotFoundError
            else Adapter found
                Gateway->>Adapter: send(message)
                Adapter->>Adapter: parse thread id
                alt Has files
                    loop For each file (concurrency=1)
                        Adapter->>Files: read(GatewayFile)
                        Files-->>Adapter: bytes
                        Adapter->>Api: sendMedia(method, field, chatId, bytes, caption)
                        Api->>TelegramAPI: HTTP POST /sendPhoto|/sendAudio|...
                        TelegramAPI-->>Api: TelegramMessage
                        Api-->>Adapter: message_id
                    end
                    Adapter-->>Gateway: SentMessage (array of messageIds)
                else Text only
                    Adapter->>Api: sendText(chatId, threadId, text)
                    Api->>TelegramAPI: HTTP POST /sendMessage
                    TelegramAPI-->>Api: TelegramMessage
                    Api-->>Adapter: message_id
                    Adapter-->>Gateway: SentMessage
                end
                Gateway-->>App: SentMessage
            end
        end
    end
Loading

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

});
}

const incoming = Stream.mergeAll(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Missing error isolation in merged incoming stream: Stream.mergeAll terminates the entire stream when any single adapter stream fails. A transient network error in the Telegram adapter would cut off incoming messages from all adapters. Wrap each adapter's stream with Stream.catchAll or Stream.catchTag to isolate failures per adapter (log and resume, or restart the adapter stream) so one adapter's error doesn't bring down the whole gateway.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/src/gateway/Gateway.ts, line 56:

<comment>Missing error isolation in merged incoming stream: `Stream.mergeAll` terminates the entire stream when any single adapter stream fails. A transient network error in the Telegram adapter would cut off incoming messages from all adapters. Wrap each adapter's stream with `Stream.catchAll` or `Stream.catchTag` to isolate failures per adapter (log and resume, or restart the adapter stream) so one adapter's error doesn't bring down the whole gateway.</comment>

<file context>
@@ -0,0 +1,93 @@
+    });
+  }
+
+  const incoming = Stream.mergeAll(
+    adapters.map((adapter) => adapter.incoming),
+    { concurrency: "unbounded", bufferSize: 16 },
</file context>


const retrySchedule = Schedule.min([
Schedule.exponential(settings.retryBaseDelayMillis),
Schedule.spaced(Math.max(settings.retryBaseDelayMillis, settings.retryMaxDelayMillis)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A configured retry maximum below the base delay is ignored, so polling can wait longer than retryMaxDelayMillis. Use the configured maximum as the cap (or reject this invalid config combination).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/src/gateway/adapters/telegram/TelegramAdapter.ts, line 286:

<comment>A configured retry maximum below the base delay is ignored, so polling can wait longer than `retryMaxDelayMillis`. Use the configured maximum as the cap (or reject this invalid config combination).</comment>

<file context>
@@ -0,0 +1,353 @@
+
+  const retrySchedule = Schedule.min([
+    Schedule.exponential(settings.retryBaseDelayMillis),
+    Schedule.spaced(Math.max(settings.retryBaseDelayMillis, settings.retryMaxDelayMillis)),
+  ]).pipe(
+    Schedule.tap(({ input }) =>
</file context>


const TelegramFileFields = {
file_id: Schema.String,
file_unique_id: Schema.optionalKey(Schema.String),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: file_unique_id in TelegramFileFields is modeled with Schema.optionalKey, but the Telegram Bot API guarantees file_unique_id is always present on all file objects (File, PhotoSize, Document, Audio, Video, Voice). This means the TypeScript type will have file_unique_id?: string | undefined when Telegram never omits it, forcing unnecessary defensive ?? / ?. checks in consumer code. Use Schema.String instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/src/gateway/adapters/telegram/TelegramModels.ts, line 5:

<comment>`file_unique_id` in `TelegramFileFields` is modeled with `Schema.optionalKey`, but the Telegram Bot API guarantees `file_unique_id` is always present on all file objects (`File`, `PhotoSize`, `Document`, `Audio`, `Video`, `Voice`). This means the TypeScript type will have `file_unique_id?: string | undefined` when Telegram never omits it, forcing unnecessary defensive `??` / `?.` checks in consumer code. Use `Schema.String` instead.</comment>

<file context>
@@ -0,0 +1,103 @@
+
+const TelegramFileFields = {
+  file_id: Schema.String,
+  file_unique_id: Schema.optionalKey(Schema.String),
+  file_size: Schema.optionalKey(Schema.Finite),
+};
</file context>

};

export const TelegramUser = Schema.Struct({
id: Schema.Finite,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Numeric fields like id, message_id, date, width, height, duration, and file_size use Schema.Finite, which accepts any finite number including floats. The Telegram Bot API always returns integers for these fields. The codebase already has const NonNegativeInteger = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)) in packages/contracts/src/gateway.ts — using that pattern (or at minimum Schema.Finite.check(Schema.isInt())) here would catch non-integer values that Schema.Finite silently accepts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/src/gateway/adapters/telegram/TelegramModels.ts, line 10:

<comment>Numeric fields like `id`, `message_id`, `date`, `width`, `height`, `duration`, and `file_size` use `Schema.Finite`, which accepts any finite number including floats. The Telegram Bot API always returns integers for these fields. The codebase already has `const NonNegativeInteger = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))` in `packages/contracts/src/gateway.ts` — using that pattern (or at minimum `Schema.Finite.check(Schema.isInt())`) here would catch non-integer values that `Schema.Finite` silently accepts.</comment>

<file context>
@@ -0,0 +1,103 @@
+};
+
+export const TelegramUser = Schema.Struct({
+  id: Schema.Finite,
+  is_bot: Schema.Boolean,
+  first_name: Schema.String,
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant